@pi-archimedes/subagent 2.7.3 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/dispatch.test.ts +292 -0
- package/src/dispatch.ts +175 -0
- package/src/execute.test.ts +180 -0
- package/src/execute.ts +81 -43
- package/src/index.ts +4 -0
- package/src/spawn.test.ts +119 -0
- package/src/spawn.ts +46 -8
- package/src/stream.test.ts +36 -0
- package/src/stream.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/subagent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
],
|
|
16
16
|
"main": "./src/index.ts",
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@pi-archimedes/core": "2.
|
|
19
|
-
"@pi-archimedes/todo": "2.
|
|
18
|
+
"@pi-archimedes/core": "2.9.0",
|
|
19
|
+
"@pi-archimedes/todo": "2.9.0"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"@earendil-works/pi-ai": ">=0.1.0",
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"typebox": ">=1.1.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@earendil-works/pi-ai": "^0.
|
|
29
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
30
|
-
"@earendil-works/pi-tui": "^0.
|
|
28
|
+
"@earendil-works/pi-ai": "^0.87.0",
|
|
29
|
+
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
30
|
+
"@earendil-works/pi-tui": "^0.87.0",
|
|
31
31
|
"@types/node": "^22.0.0",
|
|
32
32
|
"typebox": "^1.1.38",
|
|
33
33
|
"typescript": "^6.0.3"
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// ── Bridge-mode dispatch (dispatchViaBridge) ─────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A fake bridge server (a `net` server on a temp socket — the pattern from
|
|
4
|
+
// packages/core's bridge index.test.ts end-to-end section) stands in for the
|
|
5
|
+
// desktop. The bridge is activated through the real env-gated session_start
|
|
6
|
+
// path (registerBridge), so `getBridge().active` is the real gate.
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import * as net from "node:net";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { registerBridge } from "@pi-archimedes/core/bridge";
|
|
16
|
+
import { dispatchViaBridge } from "./dispatch.js";
|
|
17
|
+
import { resolveModel } from "./spawn.js";
|
|
18
|
+
import type { ExecuteOptions } from "./execute.js";
|
|
19
|
+
import type { AgentConfig } from "./agents.js";
|
|
20
|
+
import type { SubagentResult } from "./types.js";
|
|
21
|
+
|
|
22
|
+
const ENV_KEYS = [
|
|
23
|
+
"PI_ARCHIMEDES_BRIDGE",
|
|
24
|
+
"PI_ARCHIMEDES_BRIDGE_SOCKET",
|
|
25
|
+
"PI_ARCHIMEDES_BRIDGE_SESSION",
|
|
26
|
+
"PI_ARCHIMEDES_BRIDGE_SERVER_PID",
|
|
27
|
+
"PI_SUBAGENT_SOCKET",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
31
|
+
|
|
32
|
+
function clearBridgeEnv(): void {
|
|
33
|
+
for (const k of ENV_KEYS) delete process.env[k];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Activate the bridge (env-gated, root-only) pointing at the given socket. */
|
|
37
|
+
function activateBridge(socketPath: string): void {
|
|
38
|
+
clearBridgeEnv();
|
|
39
|
+
process.env.PI_ARCHIMEDES_BRIDGE = "1";
|
|
40
|
+
process.env.PI_ARCHIMEDES_BRIDGE_SOCKET = socketPath;
|
|
41
|
+
process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "test-desktop-session";
|
|
42
|
+
process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID = "12345";
|
|
43
|
+
// registerBridge evaluates isBridgeMode at session_start — fire the handler.
|
|
44
|
+
const onSpy = vi.fn();
|
|
45
|
+
registerBridge({ on: onSpy } as unknown as ExtensionAPI);
|
|
46
|
+
const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
|
|
47
|
+
const startCall = calls.find((c) => c[0] === "session_start");
|
|
48
|
+
expect(startCall).toBeTruthy();
|
|
49
|
+
startCall;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tempSocketPath(): string {
|
|
53
|
+
return path.join(os.tmpdir(), `dispatch-test-${randomUUID()}.sock`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const AGENT: AgentConfig = {
|
|
57
|
+
name: "reviewer",
|
|
58
|
+
description: "Code reviewer",
|
|
59
|
+
systemPrompt: "You are a reviewer.\n",
|
|
60
|
+
source: "global",
|
|
61
|
+
filePath: "/tmp/reviewer.md",
|
|
62
|
+
model: "anthropic/claude-agent",
|
|
63
|
+
thinking: "medium",
|
|
64
|
+
tools: ["read", "bash"],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function makeOptions(overrides: Partial<ExecuteOptions> = {}): ExecuteOptions {
|
|
68
|
+
return {
|
|
69
|
+
agent: "reviewer",
|
|
70
|
+
agentConfig: AGENT,
|
|
71
|
+
task: "review this code",
|
|
72
|
+
model: "call-model",
|
|
73
|
+
activeModel: "active-model",
|
|
74
|
+
cwd: undefined,
|
|
75
|
+
signal: undefined,
|
|
76
|
+
onUpdate: undefined,
|
|
77
|
+
toolCallId: "tc-1",
|
|
78
|
+
...overrides,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A fake bridge server: reply to every request frame with the given frame body. */
|
|
83
|
+
function startFakeDesktop(
|
|
84
|
+
sockPath: string,
|
|
85
|
+
reply: (msg: { id?: string; type?: string; method?: string; source?: string; toolCallId?: string; params?: unknown }) => unknown,
|
|
86
|
+
): Promise<net.Server> {
|
|
87
|
+
const server = net.createServer((socket) => {
|
|
88
|
+
let buffer = "";
|
|
89
|
+
socket.on("data", (chunk: Buffer) => {
|
|
90
|
+
buffer += chunk.toString("utf-8");
|
|
91
|
+
const lines = buffer.split("\n");
|
|
92
|
+
buffer = lines.pop() ?? "";
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
const trimmed = line.trim();
|
|
95
|
+
if (!trimmed) continue;
|
|
96
|
+
try {
|
|
97
|
+
const msg = JSON.parse(trimmed) as { type?: string; id?: string; method?: string; source?: string; toolCallId?: string; params?: unknown };
|
|
98
|
+
if (msg.type === "request" && msg.id) {
|
|
99
|
+
const body = reply(msg);
|
|
100
|
+
if (body !== undefined) socket.write(JSON.stringify(body) + "\n");
|
|
101
|
+
}
|
|
102
|
+
} catch { /* malformed — ignore */ }
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
socket.on("error", () => { /* connection dropped */ });
|
|
106
|
+
});
|
|
107
|
+
return new Promise((resolve) => server.listen(sockPath, () => resolve(server)));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
|
|
112
|
+
clearBridgeEnv();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
for (const k of ENV_KEYS) {
|
|
117
|
+
if (savedEnv[k] === undefined) delete process.env[k];
|
|
118
|
+
else process.env[k] = savedEnv[k];
|
|
119
|
+
}
|
|
120
|
+
clearBridgeEnv();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── dispatchViaBridge: response mapping ───────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe("dispatchViaBridge", () => {
|
|
126
|
+
it("(1) maps a success response to a SubagentResult (no fork)", async () => {
|
|
127
|
+
const sockPath = tempSocketPath();
|
|
128
|
+
const frames: Array<Record<string, unknown>> = [];
|
|
129
|
+
const server = await startFakeDesktop(sockPath, (msg) => {
|
|
130
|
+
frames.push(msg);
|
|
131
|
+
return {
|
|
132
|
+
v: 1,
|
|
133
|
+
type: "response",
|
|
134
|
+
id: msg.id,
|
|
135
|
+
result: { output: "done", metrics: { inputTokens: 1, outputTokens: 2, cost: 0.01, durationMs: 5 } },
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
activateBridge(sockPath);
|
|
139
|
+
|
|
140
|
+
const outcome = await dispatchViaBridge(makeOptions(), undefined, "tc-1");
|
|
141
|
+
|
|
142
|
+
// A SubagentResult — NOT the fallback marker.
|
|
143
|
+
expect("fallback" in outcome).toBe(false);
|
|
144
|
+
const result = outcome as SubagentResult;
|
|
145
|
+
expect(result.exitCode).toBe(0);
|
|
146
|
+
expect(result.finalOutput).toBe("done");
|
|
147
|
+
expect(result.error).toBeUndefined();
|
|
148
|
+
expect(result.usage).toEqual({ input: 1, output: 2, cacheRead: 0, cacheWrite: 0, cost: 0.01, turns: 0 });
|
|
149
|
+
expect(result.model).toBe("anthropic/claude-agent"); // agent.model wins
|
|
150
|
+
// Synthesized progress (the same defensive synthesis executeSubagent does).
|
|
151
|
+
expect(result.progress).toBeDefined();
|
|
152
|
+
expect(result.progress!.status).toBe("completed");
|
|
153
|
+
expect(result.progress!.agent).toBe("reviewer");
|
|
154
|
+
expect(result.progress!.durationMs).toBe(5);
|
|
155
|
+
expect(result.progressSummary).toEqual({ toolCount: 0, tokens: 3, durationMs: 5 });
|
|
156
|
+
|
|
157
|
+
// The frame follows the wire contract.
|
|
158
|
+
const frame = frames.find((f) => f.type === "request");
|
|
159
|
+
expect(frame).toBeDefined();
|
|
160
|
+
expect(frame!.method).toBe("dispatch_subagent");
|
|
161
|
+
expect(frame!.source).toBe("main");
|
|
162
|
+
expect(frame!.toolCallId).toBe("tc-1");
|
|
163
|
+
const params = frame!.params as Record<string, unknown>;
|
|
164
|
+
expect(params.agentName).toBe("reviewer");
|
|
165
|
+
expect(params.task).toBe("review this code");
|
|
166
|
+
expect(params.systemPrompt).toBe("You are a reviewer."); // trimmed
|
|
167
|
+
expect(params.model).toBe("anthropic/claude-agent");
|
|
168
|
+
expect(params.thinking).toBe("medium");
|
|
169
|
+
expect(params.tools).toEqual(["read", "bash"]);
|
|
170
|
+
expect("cwd" in params).toBe(false); // intentionally NOT sent
|
|
171
|
+
|
|
172
|
+
server.close();
|
|
173
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("(2) a terminal error: 'cancelled' frame → a FAILED SubagentResult (NOT a fallback)", async () => {
|
|
177
|
+
const sockPath = tempSocketPath();
|
|
178
|
+
const server = await startFakeDesktop(sockPath, (msg) => {
|
|
179
|
+
return { v: 1, type: "response", id: msg.id, error: "cancelled" };
|
|
180
|
+
});
|
|
181
|
+
activateBridge(sockPath);
|
|
182
|
+
|
|
183
|
+
const outcome = await dispatchViaBridge(makeOptions(), undefined, "tc-1");
|
|
184
|
+
|
|
185
|
+
// The desktop answered — its outcome is authoritative, never a fallback.
|
|
186
|
+
expect("fallback" in outcome).toBe(false);
|
|
187
|
+
const result = outcome as SubagentResult;
|
|
188
|
+
expect(result.exitCode).toBe(1);
|
|
189
|
+
expect(result.error).toBe("cancelled");
|
|
190
|
+
expect(result.finalOutput).toBeUndefined();
|
|
191
|
+
expect(result.progress!.status).toBe("failed");
|
|
192
|
+
expect(result.progress!.error).toBe("cancelled");
|
|
193
|
+
|
|
194
|
+
server.close();
|
|
195
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("a mid-dispatch abort settles as a FAILED SubagentResult with error 'cancelled' (NOT a fallback — no fork)", async () => {
|
|
199
|
+
const sockPath = tempSocketPath();
|
|
200
|
+
const server = await startFakeDesktop(sockPath, () => {
|
|
201
|
+
/* accept but never respond */
|
|
202
|
+
});
|
|
203
|
+
activateBridge(sockPath);
|
|
204
|
+
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
const p = dispatchViaBridge(makeOptions({ signal: controller.signal }), controller.signal, "tc-1");
|
|
207
|
+
await new Promise((r) => setTimeout(r, 50)); // let the socket connect (request sent)
|
|
208
|
+
controller.abort(); // mid-dispatch: after the request, before the response
|
|
209
|
+
|
|
210
|
+
const outcome = await p;
|
|
211
|
+
// A deliberate cancel must NEVER fork: a FAILED SubagentResult with the
|
|
212
|
+
// "cancelled" outcome (the same as the desktop's terminal `error:
|
|
213
|
+
// "cancelled" frame) — NOT the fallback marker.
|
|
214
|
+
expect("fallback" in outcome).toBe(false);
|
|
215
|
+
const result = outcome as SubagentResult;
|
|
216
|
+
expect(result.exitCode).toBe(1);
|
|
217
|
+
expect(result.error).toBe("cancelled");
|
|
218
|
+
expect(result.finalOutput).toBeUndefined();
|
|
219
|
+
expect(result.progress!.status).toBe("failed");
|
|
220
|
+
expect(result.progress!.error).toBe("cancelled");
|
|
221
|
+
|
|
222
|
+
server.close();
|
|
223
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("any other response-carrying error → a FAILED SubagentResult with the error message (NOT a fallback)", async () => {
|
|
227
|
+
const sockPath = tempSocketPath();
|
|
228
|
+
const server = await startFakeDesktop(sockPath, (msg) => {
|
|
229
|
+
return { v: 1, type: "response", id: msg.id, error: "worker runtime unavailable" };
|
|
230
|
+
});
|
|
231
|
+
activateBridge(sockPath);
|
|
232
|
+
|
|
233
|
+
const outcome = await dispatchViaBridge(makeOptions(), undefined, "tc-1");
|
|
234
|
+
|
|
235
|
+
expect("fallback" in outcome).toBe(false);
|
|
236
|
+
const result = outcome as SubagentResult;
|
|
237
|
+
expect(result.exitCode).toBe(1);
|
|
238
|
+
expect(result.error).toBe("worker runtime unavailable");
|
|
239
|
+
expect(result.progress!.status).toBe("failed");
|
|
240
|
+
|
|
241
|
+
server.close();
|
|
242
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("(3) an unreachable bridge (no response frame) → { fallback: true }", async () => {
|
|
246
|
+
// No server on this socket → ECONNREFUSED → BridgeTransportError.
|
|
247
|
+
activateBridge(tempSocketPath());
|
|
248
|
+
|
|
249
|
+
const outcome = await dispatchViaBridge(makeOptions(), undefined, "tc-1");
|
|
250
|
+
expect(outcome).toEqual({ fallback: true });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("nullifies the params (model/thinking/tools/systemPrompt) for a config-less run", async () => {
|
|
254
|
+
const sockPath = tempSocketPath();
|
|
255
|
+
const frames: Array<Record<string, unknown>> = [];
|
|
256
|
+
const server = await startFakeDesktop(sockPath, (msg) => {
|
|
257
|
+
frames.push(msg);
|
|
258
|
+
return { v: 1, type: "response", id: msg.id, result: { output: "ok", metrics: { inputTokens: 0, outputTokens: 0, cost: 0, durationMs: 1 } } };
|
|
259
|
+
});
|
|
260
|
+
activateBridge(sockPath);
|
|
261
|
+
|
|
262
|
+
const outcome = await dispatchViaBridge(
|
|
263
|
+
makeOptions({ agent: undefined, agentConfig: undefined, model: undefined, activeModel: undefined }),
|
|
264
|
+
undefined,
|
|
265
|
+
"tc-1",
|
|
266
|
+
);
|
|
267
|
+
expect("fallback" in outcome).toBe(false);
|
|
268
|
+
|
|
269
|
+
const params = (frames.find((f) => f.type === "request")?.params ?? {}) as Record<string, unknown>;
|
|
270
|
+
expect(params.agentName).toBe("subagent");
|
|
271
|
+
expect(params.systemPrompt).toBeNull();
|
|
272
|
+
expect(params.model).toBeNull();
|
|
273
|
+
expect(params.thinking).toBeNull();
|
|
274
|
+
expect(params.tools).toBeNull();
|
|
275
|
+
// The result's model is the (un-nullified) resolution — undefined here.
|
|
276
|
+
expect((outcome as SubagentResult).model).toBeUndefined();
|
|
277
|
+
|
|
278
|
+
server.close();
|
|
279
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── resolveModel precedence (shared fork + bridge) ────────────────────────
|
|
284
|
+
|
|
285
|
+
describe("resolveModel", () => {
|
|
286
|
+
it("(4) agent.model > call model > activeModel; all undefined → undefined", () => {
|
|
287
|
+
expect(resolveModel({ model: "call-model", activeModel: "active-model", agent: AGENT })).toBe("anthropic/claude-agent");
|
|
288
|
+
expect(resolveModel({ model: "call-model", activeModel: "active-model", agent: undefined })).toBe("call-model");
|
|
289
|
+
expect(resolveModel({ model: undefined, activeModel: "active-model", agent: undefined })).toBe("active-model");
|
|
290
|
+
expect(resolveModel({ model: undefined, activeModel: undefined, agent: undefined })).toBeUndefined();
|
|
291
|
+
});
|
|
292
|
+
});
|
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// ── Bridge-mode dispatch (dispatch_subagent over the bridge channel) ────
|
|
2
|
+
//
|
|
3
|
+
// In bridge mode the subagent tool does NOT fork a child pi process — it
|
|
4
|
+
// sends a `dispatch_subagent` bridge request and the desktop (Client) runs
|
|
5
|
+
// the subagent session (a full ACP session on its worker runtime).
|
|
6
|
+
//
|
|
7
|
+
// Fallback rule (the BridgeTransportError distinction is the trigger):
|
|
8
|
+
// - NO response frame (unreachable socket / ECONNREFUSED, a mid-connection
|
|
9
|
+
// error, or a clean close before any response — the desktop is
|
|
10
|
+
// effectively gone) → { fallback: true } — the fork path keeps the
|
|
11
|
+
// subagent working (the main agent is still alive — its desktop
|
|
12
|
+
// connection died, not the agent).
|
|
13
|
+
// - A response-carrying `error` (the desktop answered — it is alive and
|
|
14
|
+
// the outcome is authoritative, incl. the terminal `error: "cancelled"`
|
|
15
|
+
// frame) → a FAILED SubagentResult, NEVER a fallback.
|
|
16
|
+
// - A DETERMINISTIC LOCAL CANCEL (the tool's AbortSignal → the channel's
|
|
17
|
+
// cancel() settles with a BridgeCancelledError — the desktop is NOT
|
|
18
|
+
// gone, the user just cancelled) → a FAILED SubagentResult with error
|
|
19
|
+
// "cancelled" (the same outcome as the desktop's terminal
|
|
20
|
+
// `error: "cancelled" frame), NEVER a fallback — a deliberate cancel
|
|
21
|
+
// must never fork. The cancel is detected by TYPE
|
|
22
|
+
// (`instanceof BridgeCancelledError`), not by the message string — the
|
|
23
|
+
// class is the contract between core and this package.
|
|
24
|
+
//
|
|
25
|
+
// The pre-aborted-signal + unreachable-channel corner (cancel() is a no-op
|
|
26
|
+
// there — the executor's early return leaves `finish` as the safe no-op — so
|
|
27
|
+
// the rejection WOULD map to { fallback: true }) is closed at the EXECUTE
|
|
28
|
+
// layer: executeSubagent short-circuits an already-aborted signal to a
|
|
29
|
+
// FAILED result (error "cancelled") before dispatching (the execute layer
|
|
30
|
+
// already has the signal; the channel-level function must not know fork
|
|
31
|
+
// semantics).
|
|
32
|
+
|
|
33
|
+
import { dispatch, BridgeTransportError, BridgeCancelledError } from "@pi-archimedes/core/bridge";
|
|
34
|
+
import { resolveModel } from "./spawn.js";
|
|
35
|
+
import type { ExecuteOptions } from "./execute.js";
|
|
36
|
+
import type { SubagentProgress, SubagentResult } from "./types.js";
|
|
37
|
+
|
|
38
|
+
export type DispatchOutcome = SubagentResult | { fallback: true };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Synthesize a FAILED SubagentResult (a task that never completed — a
|
|
42
|
+
* response-carrying error, a deterministic local cancel, or a pre-aborted
|
|
43
|
+
* signal). Shared by the bridge error path and executeSubagent's pre-abort
|
|
44
|
+
* short-circuit (and fork-path catch) so the "cancelled" synthesis is not
|
|
45
|
+
* duplicated. The progress is the same defensive synthesis executeSubagent
|
|
46
|
+
* uses: zeros + the real durationMs (the desktop panel is the progress
|
|
47
|
+
* surface in bridge mode; the TUI has none in RPC mode).
|
|
48
|
+
*/
|
|
49
|
+
export function buildFailedResult(
|
|
50
|
+
agentName: string,
|
|
51
|
+
task: string,
|
|
52
|
+
error: string,
|
|
53
|
+
model: string | undefined,
|
|
54
|
+
durationMs: number,
|
|
55
|
+
): SubagentResult {
|
|
56
|
+
return {
|
|
57
|
+
agent: agentName,
|
|
58
|
+
task,
|
|
59
|
+
exitCode: 1,
|
|
60
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
|
|
61
|
+
model,
|
|
62
|
+
finalOutput: undefined,
|
|
63
|
+
error,
|
|
64
|
+
progress: {
|
|
65
|
+
agent: agentName,
|
|
66
|
+
status: "failed",
|
|
67
|
+
task,
|
|
68
|
+
currentTool: undefined,
|
|
69
|
+
currentToolArgs: undefined,
|
|
70
|
+
currentToolStartedAt: undefined,
|
|
71
|
+
toolCount: 0,
|
|
72
|
+
inputTokens: 0,
|
|
73
|
+
outputTokens: 0,
|
|
74
|
+
tokens: 0,
|
|
75
|
+
cost: 0,
|
|
76
|
+
durationMs,
|
|
77
|
+
error,
|
|
78
|
+
output: undefined,
|
|
79
|
+
recentOutput: undefined,
|
|
80
|
+
toolCalls: undefined,
|
|
81
|
+
model,
|
|
82
|
+
},
|
|
83
|
+
progressSummary: { toolCount: 0, tokens: 0, durationMs },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function dispatchViaBridge(
|
|
88
|
+
options: ExecuteOptions,
|
|
89
|
+
signal: AbortSignal | undefined,
|
|
90
|
+
toolCallId: string | undefined,
|
|
91
|
+
): Promise<DispatchOutcome> {
|
|
92
|
+
const agentName = options.agent ?? "subagent";
|
|
93
|
+
const model = resolveModel({ model: options.model, activeModel: options.activeModel, agent: options.agentConfig });
|
|
94
|
+
const startTime = Date.now();
|
|
95
|
+
|
|
96
|
+
// `cwd` is intentionally NOT sent — the subagent session's cwd is the
|
|
97
|
+
// parent's Space folder (the wire contract; the tool's `cwd` param is
|
|
98
|
+
// ignored in bridge mode).
|
|
99
|
+
const params = {
|
|
100
|
+
agentName,
|
|
101
|
+
task: options.task,
|
|
102
|
+
systemPrompt: options.agentConfig?.systemPrompt?.trim() || null,
|
|
103
|
+
model: model ?? null,
|
|
104
|
+
thinking: options.agentConfig?.thinking ?? null,
|
|
105
|
+
tools: options.agentConfig?.tools?.length ? options.agentConfig.tools : null,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return dispatch(params, toolCallId, signal).then(
|
|
109
|
+
(res) => {
|
|
110
|
+
// Success → a SubagentResult with a synthesized progress (the same
|
|
111
|
+
// defensive synthesis executeSubagent does; the desktop's panel is the
|
|
112
|
+
// progress surface, so the fields are zeros + the real durationMs).
|
|
113
|
+
const durationMs = res.metrics.durationMs;
|
|
114
|
+
const progress: SubagentProgress = {
|
|
115
|
+
agent: agentName,
|
|
116
|
+
status: "completed",
|
|
117
|
+
task: options.task,
|
|
118
|
+
currentTool: undefined,
|
|
119
|
+
currentToolArgs: undefined,
|
|
120
|
+
currentToolStartedAt: undefined,
|
|
121
|
+
toolCount: 0,
|
|
122
|
+
inputTokens: 0,
|
|
123
|
+
outputTokens: 0,
|
|
124
|
+
tokens: 0,
|
|
125
|
+
cost: 0,
|
|
126
|
+
durationMs,
|
|
127
|
+
error: undefined,
|
|
128
|
+
output: res.output,
|
|
129
|
+
recentOutput: undefined,
|
|
130
|
+
toolCalls: undefined,
|
|
131
|
+
model,
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
agent: agentName,
|
|
135
|
+
task: options.task,
|
|
136
|
+
exitCode: 0,
|
|
137
|
+
usage: {
|
|
138
|
+
input: res.metrics.inputTokens,
|
|
139
|
+
output: res.metrics.outputTokens,
|
|
140
|
+
cacheRead: 0,
|
|
141
|
+
cacheWrite: 0,
|
|
142
|
+
cost: res.metrics.cost,
|
|
143
|
+
turns: 0,
|
|
144
|
+
},
|
|
145
|
+
model,
|
|
146
|
+
finalOutput: res.output,
|
|
147
|
+
error: undefined,
|
|
148
|
+
progress,
|
|
149
|
+
progressSummary: {
|
|
150
|
+
toolCount: 0,
|
|
151
|
+
tokens: res.metrics.inputTokens + res.metrics.outputTokens,
|
|
152
|
+
durationMs,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
(err: unknown) => {
|
|
157
|
+
// No response frame (the desktop is effectively gone) → the fork path
|
|
158
|
+
// keeps the subagent working.
|
|
159
|
+
if (err instanceof BridgeTransportError) {
|
|
160
|
+
return { fallback: true };
|
|
161
|
+
}
|
|
162
|
+
// A response-carrying error (the desktop answered — authoritative,
|
|
163
|
+
// incl. the terminal "cancelled" frame) or a DETERMINISTIC LOCAL CANCEL
|
|
164
|
+
// (the tool's AbortSignal → channel.cancel() settles with a
|
|
165
|
+
// BridgeCancelledError — the same outcome as the desktop's terminal
|
|
166
|
+
// `error: "cancelled" frame) → a FAILED SubagentResult (the shared
|
|
167
|
+
// failed-progress synthesis), NEVER a fallback (a deliberate cancel
|
|
168
|
+
// must never fork).
|
|
169
|
+
const isLocalCancel = err instanceof BridgeCancelledError;
|
|
170
|
+
const errorMessage = isLocalCancel ? "cancelled" : err instanceof Error ? err.message : String(err);
|
|
171
|
+
const durationMs = Date.now() - startTime;
|
|
172
|
+
return buildFailedResult(agentName, options.task, errorMessage, model, durationMs);
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// ── executeSubagent bridge branch: a deliberate cancel must never fork ──
|
|
2
|
+
//
|
|
3
|
+
// The "no fork on cancel" guarantee at the execute layer: a mid-dispatch
|
|
4
|
+
// abort settles the bridge dispatch as a FAILED result (error "cancelled")
|
|
5
|
+
// and `spawnSubagent` (the fork path) is NEVER called. The bridge is
|
|
6
|
+
// activated through the real env-gated session_start path (registerBridge),
|
|
7
|
+
// so `getBridge().active` is the real gate.
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import * as net from "node:net";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { registerBridge } from "@pi-archimedes/core/bridge";
|
|
17
|
+
import * as channel from "@pi-archimedes/core/bridge/channel";
|
|
18
|
+
import * as spawn from "./spawn.js";
|
|
19
|
+
import { executeSubagent } from "./execute.js";
|
|
20
|
+
import type { ExecuteOptions } from "./execute.js";
|
|
21
|
+
import type { AgentConfig } from "./agents.js";
|
|
22
|
+
import type { SubagentProgress } from "./types.js";
|
|
23
|
+
|
|
24
|
+
const ENV_KEYS = [
|
|
25
|
+
"PI_ARCHIMEDES_BRIDGE",
|
|
26
|
+
"PI_ARCHIMEDES_BRIDGE_SOCKET",
|
|
27
|
+
"PI_ARCHIMEDES_BRIDGE_SESSION",
|
|
28
|
+
"PI_ARCHIMEDES_BRIDGE_SERVER_PID",
|
|
29
|
+
"PI_SUBAGENT_SOCKET",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
33
|
+
|
|
34
|
+
function clearBridgeEnv(): void {
|
|
35
|
+
for (const k of ENV_KEYS) delete process.env[k];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Activate the bridge (env-gated, root-only) pointing at the given socket. */
|
|
39
|
+
function activateBridge(socketPath: string): void {
|
|
40
|
+
clearBridgeEnv();
|
|
41
|
+
process.env.PI_ARCHIMEDES_BRIDGE = "1";
|
|
42
|
+
process.env.PI_ARCHIMEDES_BRIDGE_SOCKET = socketPath;
|
|
43
|
+
process.env.PI_ARCHIMEDES_BRIDGE_SESSION = "test-desktop-session";
|
|
44
|
+
process.env.PI_ARCHIMEDES_BRIDGE_SERVER_PID = "12345";
|
|
45
|
+
const onSpy = vi.fn();
|
|
46
|
+
registerBridge({ on: onSpy } as unknown as ExtensionAPI);
|
|
47
|
+
const calls = onSpy.mock.calls as Array<[string, (e: unknown, ctx: unknown) => void]>;
|
|
48
|
+
const startCall = calls.find((c) => c[0] === "session_start");
|
|
49
|
+
expect(startCall).toBeTruthy();
|
|
50
|
+
startCall;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tempSocketPath(): string {
|
|
54
|
+
return path.join(os.tmpdir(), `execute-test-${randomUUID()}.sock`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const AGENT: AgentConfig = {
|
|
58
|
+
name: "reviewer",
|
|
59
|
+
description: "Code reviewer",
|
|
60
|
+
systemPrompt: "You are a reviewer.\n",
|
|
61
|
+
source: "global",
|
|
62
|
+
filePath: "/tmp/reviewer.md",
|
|
63
|
+
model: "anthropic/claude-agent",
|
|
64
|
+
thinking: "medium",
|
|
65
|
+
tools: ["read", "bash"],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function makeOptions(overrides: Partial<ExecuteOptions> = {}): ExecuteOptions {
|
|
69
|
+
return {
|
|
70
|
+
agent: "reviewer",
|
|
71
|
+
agentConfig: AGENT,
|
|
72
|
+
task: "review this code",
|
|
73
|
+
model: "call-model",
|
|
74
|
+
activeModel: "active-model",
|
|
75
|
+
cwd: undefined,
|
|
76
|
+
signal: undefined,
|
|
77
|
+
onUpdate: undefined,
|
|
78
|
+
toolCallId: "tc-1",
|
|
79
|
+
...overrides,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function startSilentDesktop(sockPath: string): Promise<net.Server> {
|
|
84
|
+
const server = net.createServer((socket) => {
|
|
85
|
+
/* accept but never respond */
|
|
86
|
+
socket.on("error", () => { /* connection dropped */ });
|
|
87
|
+
});
|
|
88
|
+
return new Promise((resolve) => server.listen(sockPath, () => resolve(server)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
|
|
93
|
+
clearBridgeEnv();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
for (const k of ENV_KEYS) {
|
|
98
|
+
if (savedEnv[k] === undefined) delete process.env[k];
|
|
99
|
+
else process.env[k] = savedEnv[k];
|
|
100
|
+
}
|
|
101
|
+
clearBridgeEnv();
|
|
102
|
+
vi.restoreAllMocks();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("executeSubagent (bridge branch)", () => {
|
|
106
|
+
it("a mid-dispatch abort settles as a FAILED result and NEVER forks (spawnSubagent not called)", async () => {
|
|
107
|
+
const sockPath = tempSocketPath();
|
|
108
|
+
const server = await startSilentDesktop(sockPath);
|
|
109
|
+
activateBridge(sockPath);
|
|
110
|
+
|
|
111
|
+
// The fork path: if a deliberate cancel ever fell through to it, the
|
|
112
|
+
// spy catches the call (the implementation throws so a fall-through is
|
|
113
|
+
// also loud).
|
|
114
|
+
const spawnSpy = vi.spyOn(spawn, "spawnSubagent").mockImplementation(() => {
|
|
115
|
+
throw new Error("fork must not happen on a deliberate cancel");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const p = executeSubagent(makeOptions({ signal: controller.signal }));
|
|
120
|
+
await new Promise((r) => setTimeout(r, 50)); // let the socket connect (request sent)
|
|
121
|
+
controller.abort(); // mid-dispatch: after the request, before the response
|
|
122
|
+
|
|
123
|
+
const result = await p;
|
|
124
|
+
// The bridge outcome is authoritative (a FAILED result) — the fork path
|
|
125
|
+
// (spawnSubagent) is NEVER called for a task the user just cancelled.
|
|
126
|
+
expect(result.exitCode).toBe(1);
|
|
127
|
+
expect(result.error).toBe("cancelled");
|
|
128
|
+
expect(result.progress!.status).toBe("failed");
|
|
129
|
+
expect(spawnSpy).not.toHaveBeenCalled();
|
|
130
|
+
|
|
131
|
+
spawnSpy.mockRestore();
|
|
132
|
+
server.close();
|
|
133
|
+
try { fs.unlinkSync(sockPath); } catch { /* already gone */ }
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("a pre-aborted signal short-circuits to a FAILED result and NEVER forks (no 'dispatched' placeholder, no fork)", async () => {
|
|
137
|
+
// The corner: an UNREACHABLE channel (the executor's early return leaves
|
|
138
|
+
// `finish` as a safe no-op, so `handle.cancel()` is a no-op and
|
|
139
|
+
// `channel.request` rejects with a BridgeTransportError → { fallback: true }
|
|
140
|
+
// → the fork path would run for an already-cancelled task). Simulated with
|
|
141
|
+
// `active: true` + no socket target (the ECONNREFUSED variant is NOT the
|
|
142
|
+
// corner — there the async socket error loses the race to the synchronous
|
|
143
|
+
// cancel() and the outcome is already a deterministic "cancelled").
|
|
144
|
+
activateBridge(tempSocketPath());
|
|
145
|
+
channel.configure({ active: true, socketPath: undefined });
|
|
146
|
+
|
|
147
|
+
// The fork path: if the pre-abort corner ever fell through to it, the spy
|
|
148
|
+
// catches the call (the implementation throws so a fall-through is also
|
|
149
|
+
// loud).
|
|
150
|
+
const spawnSpy = vi.spyOn(spawn, "spawnSubagent").mockImplementation(() => {
|
|
151
|
+
throw new Error("fork must not happen for a pre-aborted signal");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
controller.abort(); // pre-aborted BEFORE dispatch
|
|
156
|
+
|
|
157
|
+
const updates: SubagentProgress[] = [];
|
|
158
|
+
const result = await executeSubagent(
|
|
159
|
+
makeOptions({ signal: controller.signal, onUpdate: (p) => updates.push(p) }),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// A deliberate cancel must NEVER fork — even in the unreachable-channel
|
|
163
|
+
// corner: a FAILED SubagentResult with the "cancelled" outcome, and the
|
|
164
|
+
// task does not claim it was "dispatched" (no placeholder before the
|
|
165
|
+
// short-circuit; the final-failure progress still updates the
|
|
166
|
+
// executeParallel slot from the pending placeholder to "failed").
|
|
167
|
+
expect(result.exitCode).toBe(1);
|
|
168
|
+
expect(result.error).toBe("cancelled");
|
|
169
|
+
expect(result.progress!.status).toBe("failed");
|
|
170
|
+
expect(result.progress!.error).toBe("cancelled");
|
|
171
|
+
expect(spawnSpy).not.toHaveBeenCalled();
|
|
172
|
+
// Exactly ONE update: the final-failure progress (no "dispatched"
|
|
173
|
+
// placeholder — a cancelled task does not claim it was dispatched).
|
|
174
|
+
expect(updates).toHaveLength(1);
|
|
175
|
+
expect(updates[0]?.status).toBe("failed");
|
|
176
|
+
expect(updates[0]?.error).toBe("cancelled");
|
|
177
|
+
|
|
178
|
+
spawnSpy.mockRestore();
|
|
179
|
+
});
|
|
180
|
+
});
|
package/src/execute.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getBridge } from "@pi-archimedes/core/bridge";
|
|
2
|
+
import { spawnSubagent, resolveModel } from "./spawn.js";
|
|
3
|
+
import { dispatchViaBridge, buildFailedResult } from "./dispatch.js";
|
|
2
4
|
import { streamEvents } from "./stream.js";
|
|
3
5
|
import { emitCostUpdate } from "./cost.js";
|
|
4
6
|
import type { AgentConfig } from "./agents.js";
|
|
5
|
-
import type { SubagentProgress, SubagentResult
|
|
7
|
+
import type { SubagentProgress, SubagentResult } from "./types.js";
|
|
6
8
|
|
|
7
9
|
export interface ExecuteOptions {
|
|
8
10
|
agent: string | undefined;
|
|
@@ -13,6 +15,10 @@ export interface ExecuteOptions {
|
|
|
13
15
|
cwd: string | undefined;
|
|
14
16
|
signal: AbortSignal | undefined;
|
|
15
17
|
onUpdate: ((progress: SubagentProgress) => void) | undefined;
|
|
18
|
+
/** The subagent tool-call id (the tool's `_id`) — rides the dispatch frame
|
|
19
|
+
* for Client correlation; `undefined` → the frame's `toolCallId` is absent.
|
|
20
|
+
* In `executeParallel` all N tasks share the single subagent tool-call's id. */
|
|
21
|
+
toolCallId?: string;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/**
|
|
@@ -22,6 +28,71 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
|
|
|
22
28
|
const agentName = options.agent ?? "subagent";
|
|
23
29
|
const startTime = Date.now();
|
|
24
30
|
|
|
31
|
+
// Bridge mode: dispatch to the desktop (Client) instead of forking a child
|
|
32
|
+
// pi process. An UNREACHABLE bridge (no response frame — e.g. macOS, where
|
|
33
|
+
// the desktop's listener is not started, or Windows, where it is a no-op
|
|
34
|
+
// stub) falls back to the fork path so subagents keep working everywhere.
|
|
35
|
+
if (getBridge().active) {
|
|
36
|
+
const model = resolveModel({ model: options.model, activeModel: options.activeModel, agent: options.agentConfig });
|
|
37
|
+
|
|
38
|
+
// A signal that is ALREADY aborted before dispatch: short-circuit to a
|
|
39
|
+
// FAILED result (error "cancelled") — a deliberate cancel must never
|
|
40
|
+
// fork, even in the corner where the channel is unreachable (the
|
|
41
|
+
// executor's early return leaves `finish` as a safe no-op, so
|
|
42
|
+
// `handle.cancel()` is a no-op and the rejection would map to
|
|
43
|
+
// { fallback: true } → the fork path for an already-cancelled task).
|
|
44
|
+
// This layer (not dispatchViaBridge) is the home: the execute layer
|
|
45
|
+
// already has the signal; the channel-level function must not know fork
|
|
46
|
+
// semantics. The short-circuit is BEFORE the "dispatched" placeholder —
|
|
47
|
+
// a cancelled task does not claim it was dispatched — but the final
|
|
48
|
+
// failure progress IS emitted (the same emit the catch block below does),
|
|
49
|
+
// so executeParallel's progress slot updates from the pending placeholder
|
|
50
|
+
// to "failed" instead of a stale "Starting...".
|
|
51
|
+
if (options.signal?.aborted) {
|
|
52
|
+
const result = buildFailedResult(agentName, options.task, "cancelled", model, Date.now() - startTime);
|
|
53
|
+
options.onUpdate?.(result.progress!);
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The TUI has no surface in bridge mode (RPC mode) and the desktop panel
|
|
58
|
+
// is the progress surface — call onUpdate ONCE with a minimal "dispatched"
|
|
59
|
+
// placeholder so the pi-acp tool_call_update frames stay well-formed, then
|
|
60
|
+
// no further updates.
|
|
61
|
+
options.onUpdate?.({
|
|
62
|
+
agent: agentName,
|
|
63
|
+
status: "running",
|
|
64
|
+
task: options.task,
|
|
65
|
+
currentTool: undefined,
|
|
66
|
+
currentToolArgs: undefined,
|
|
67
|
+
currentToolStartedAt: undefined,
|
|
68
|
+
toolCount: 0,
|
|
69
|
+
inputTokens: 0,
|
|
70
|
+
outputTokens: 0,
|
|
71
|
+
tokens: 0,
|
|
72
|
+
cost: 0,
|
|
73
|
+
durationMs: 0,
|
|
74
|
+
error: undefined,
|
|
75
|
+
output: undefined,
|
|
76
|
+
recentOutput: undefined,
|
|
77
|
+
toolCalls: undefined,
|
|
78
|
+
model,
|
|
79
|
+
});
|
|
80
|
+
const outcome = await dispatchViaBridge(options, options.signal, options.toolCallId);
|
|
81
|
+
if (!("fallback" in outcome)) {
|
|
82
|
+
// Bridge used (success or a response-carrying error, incl. the terminal
|
|
83
|
+
// "cancelled" frame — the desktop is alive, its outcome is authoritative):
|
|
84
|
+
// return it — NO fork fallback, and NO emitCostUpdate here — the PARENT
|
|
85
|
+
// never pushes the subagent's usage on the PARENT's bus (the fork-delta
|
|
86
|
+
// semantics: only the fork path reports a child's usage, source
|
|
87
|
+
// `subagent:<name>`). The subagent process self-emits its own usage to
|
|
88
|
+
// its OWN bridge (the core self-usage emitter,
|
|
89
|
+
// `subagent-metrics-cost-push` Task 1) — a different bus, a different
|
|
90
|
+
// session; no double-count.
|
|
91
|
+
return outcome;
|
|
92
|
+
}
|
|
93
|
+
// { fallback: true } — unreachable bridge: fall through to the fork path.
|
|
94
|
+
}
|
|
95
|
+
|
|
25
96
|
// Track previously emitted values to only emit deltas
|
|
26
97
|
let lastEmittedInput = 0;
|
|
27
98
|
let lastEmittedOutput = 0;
|
|
@@ -95,46 +166,13 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
|
|
|
95
166
|
} catch (err) {
|
|
96
167
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
97
168
|
const durationMs = Date.now() - startTime;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
cacheRead: 0,
|
|
106
|
-
cacheWrite: 0,
|
|
107
|
-
cost: 0,
|
|
108
|
-
turns: 0,
|
|
109
|
-
} as SubagentUsage,
|
|
110
|
-
model: undefined,
|
|
111
|
-
finalOutput: undefined,
|
|
112
|
-
error: errorMessage,
|
|
113
|
-
// Always return a valid progress object so the parallel renderer's
|
|
114
|
-
// `details.progress[i]` stays aligned with `details.results[i]`.
|
|
115
|
-
// Returning undefined here would be filtered out and cause index
|
|
116
|
-
// misalignment between results and progress in the parallel view.
|
|
117
|
-
progress: {
|
|
118
|
-
agent: agentName,
|
|
119
|
-
status: "failed",
|
|
120
|
-
task: options.task,
|
|
121
|
-
currentTool: undefined,
|
|
122
|
-
currentToolArgs: undefined,
|
|
123
|
-
currentToolStartedAt: undefined,
|
|
124
|
-
toolCount: 0,
|
|
125
|
-
inputTokens: 0,
|
|
126
|
-
outputTokens: 0,
|
|
127
|
-
tokens: 0,
|
|
128
|
-
cost: 0,
|
|
129
|
-
durationMs,
|
|
130
|
-
error: errorMessage,
|
|
131
|
-
output: undefined,
|
|
132
|
-
recentOutput: undefined,
|
|
133
|
-
toolCalls: undefined,
|
|
134
|
-
model: undefined,
|
|
135
|
-
},
|
|
136
|
-
progressSummary: { toolCount: 0, tokens: 0, durationMs },
|
|
137
|
-
};
|
|
169
|
+
// The shared failed-result synthesis (the same one the bridge error path
|
|
170
|
+
// uses — model is undefined here: the fork never ran, so no model was
|
|
171
|
+
// chosen). Always returns a valid progress object so the parallel
|
|
172
|
+
// renderer's `details.progress[i]` stays aligned with `details.results[i]`
|
|
173
|
+
// (returning undefined would be filtered out and cause index misalignment
|
|
174
|
+
// between results and progress in the parallel view).
|
|
175
|
+
const result = buildFailedResult(agentName, options.task, errorMessage, undefined, durationMs);
|
|
138
176
|
// Emit final failure progress so executeParallel's progress slot updates
|
|
139
177
|
// from the pending placeholder to "failed" (prevents stale "Starting..." display).
|
|
140
178
|
options.onUpdate?.(result.progress!);
|
|
@@ -146,7 +184,7 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
|
|
|
146
184
|
* Execute multiple subagents in parallel.
|
|
147
185
|
*/
|
|
148
186
|
export async function executeParallel(options: {
|
|
149
|
-
tasks: Array<{ agent: string | undefined; agentConfig: AgentConfig | undefined; task: string; model: string | undefined; activeModel: string | undefined; cwd: string | undefined }>;
|
|
187
|
+
tasks: Array<{ agent: string | undefined; agentConfig: AgentConfig | undefined; task: string; model: string | undefined; activeModel: string | undefined; cwd: string | undefined; toolCallId?: string }>;
|
|
150
188
|
signal: AbortSignal | undefined;
|
|
151
189
|
onUpdate: ((progress: SubagentProgress[]) => void) | undefined;
|
|
152
190
|
}): Promise<SubagentResult[]> {
|
package/src/index.ts
CHANGED
|
@@ -96,6 +96,9 @@ export function registerSubagent(pi: ExtensionAPI): void {
|
|
|
96
96
|
model: t.model,
|
|
97
97
|
activeModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
98
98
|
cwd: t.cwd ?? undefined,
|
|
99
|
+
// All N tasks share the single subagent tool-call's id (the frame
|
|
100
|
+
// carries it for Client correlation).
|
|
101
|
+
toolCallId: _id,
|
|
99
102
|
})),
|
|
100
103
|
signal: signal ?? undefined,
|
|
101
104
|
onUpdate: (progress: SubagentProgress[]) => {
|
|
@@ -163,6 +166,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
|
|
|
163
166
|
activeModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
164
167
|
cwd: params.cwd ?? undefined,
|
|
165
168
|
signal: signal ?? undefined,
|
|
169
|
+
toolCallId: _id,
|
|
166
170
|
onUpdate: (progress: SubagentProgress) => {
|
|
167
171
|
onUpdate?.({
|
|
168
172
|
content: [],
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import * as net from "node:net";
|
|
3
|
+
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
4
|
+
import { startAskSocketServer } from "./spawn.js";
|
|
5
|
+
|
|
6
|
+
// startAskSocketServer is tested directly (not via spawnSubagent, which would
|
|
7
|
+
// spawn a real `pi` process). The real bus is used — subscriptions are
|
|
8
|
+
// unsubscribed in afterEach so they don't leak into other test files in the
|
|
9
|
+
// same worker (the bus is a globalThis singleton).
|
|
10
|
+
|
|
11
|
+
const cleanups: Array<() => void> = [];
|
|
12
|
+
const unsubs: Array<() => void> = [];
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
unsubs.splice(0).forEach((u) => u());
|
|
16
|
+
cleanups.splice(0).forEach((c) => c());
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function track(fn: () => void): void {
|
|
20
|
+
cleanups.push(fn);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function connectClient(socketPath: string): Promise<net.Socket> {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const client = net.connect(socketPath, () => resolve(client));
|
|
26
|
+
client.on("error", (err) => reject(err));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeAskRequest(client: net.Socket, requestId: string, toolCallId?: string): void {
|
|
31
|
+
client.write(
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
type: "ask_request",
|
|
34
|
+
requestId,
|
|
35
|
+
toolCallId,
|
|
36
|
+
questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
|
|
37
|
+
}) + "\n",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("startAskSocketServer", () => {
|
|
42
|
+
it("a child ask_request carrying toolCallId → the bus ASK_REQUEST carries the same toolCallId", async () => {
|
|
43
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent");
|
|
44
|
+
track(cleanup);
|
|
45
|
+
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
46
|
+
|
|
47
|
+
const requests: unknown[] = [];
|
|
48
|
+
unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
|
|
49
|
+
|
|
50
|
+
const client = await connectClient(socketPath);
|
|
51
|
+
writeAskRequest(client, "req-1", "tool-9");
|
|
52
|
+
|
|
53
|
+
await vi.waitFor(() => expect(requests.length).toBe(1));
|
|
54
|
+
const req = requests[0] as { source: string; requestId: string; toolCallId?: string; questions: unknown[] };
|
|
55
|
+
expect(req.source).toBe("subagent:test-agent");
|
|
56
|
+
expect(req.requestId).toBe("req-1");
|
|
57
|
+
expect(req.toolCallId).toBe("tool-9");
|
|
58
|
+
expect(req.questions).toEqual([{ id: "q1", question: "Which?", options: [{ label: "A" }] }]);
|
|
59
|
+
|
|
60
|
+
// Deliver the response (drains pending) before closing so no ASK_CANCEL
|
|
61
|
+
// leaks onto the global bus queue for the next test
|
|
62
|
+
getBus().emit(Events.ASK_RESPONSE, {
|
|
63
|
+
requestId: "req-1",
|
|
64
|
+
cancelled: false,
|
|
65
|
+
results: [{ id: "q1", selectedOptions: ["A"] }],
|
|
66
|
+
});
|
|
67
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
68
|
+
client.destroy();
|
|
69
|
+
await new Promise((r) => setTimeout(r, 50)); // close handler ran (no-op)
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("closing the child socket → ASK_CANCEL for the pending requestId + source", async () => {
|
|
73
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent");
|
|
74
|
+
track(cleanup);
|
|
75
|
+
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
76
|
+
|
|
77
|
+
const cancels: unknown[] = [];
|
|
78
|
+
unsubs.push(getBus().on(Events.ASK_CANCEL, (p) => cancels.push(p)));
|
|
79
|
+
const requests: unknown[] = [];
|
|
80
|
+
unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
|
|
81
|
+
|
|
82
|
+
const client = await connectClient(socketPath);
|
|
83
|
+
writeAskRequest(client, "req-2");
|
|
84
|
+
|
|
85
|
+
// Wait until the request is registered (pending) before closing
|
|
86
|
+
await vi.waitFor(() => expect(requests.length).toBe(1));
|
|
87
|
+
client.destroy();
|
|
88
|
+
|
|
89
|
+
await vi.waitFor(() => expect(cancels.length).toBe(1));
|
|
90
|
+
expect(cancels[0]).toEqual({ requestId: "req-2", source: "subagent:test-agent" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("no ASK_CANCEL once the response has already been delivered (pending drained)", async () => {
|
|
94
|
+
const { socketPath, cleanup } = startAskSocketServer("test-agent");
|
|
95
|
+
track(cleanup);
|
|
96
|
+
await new Promise((r) => setTimeout(r, 50)); // let the server listen
|
|
97
|
+
|
|
98
|
+
const cancels: unknown[] = [];
|
|
99
|
+
unsubs.push(getBus().on(Events.ASK_CANCEL, (p) => cancels.push(p)));
|
|
100
|
+
const requests: unknown[] = [];
|
|
101
|
+
unsubs.push(getBus().on(Events.ASK_REQUEST, (p) => requests.push(p)));
|
|
102
|
+
|
|
103
|
+
const client = await connectClient(socketPath);
|
|
104
|
+
writeAskRequest(client, "req-3");
|
|
105
|
+
await vi.waitFor(() => expect(requests.length).toBe(1));
|
|
106
|
+
|
|
107
|
+
// Deliver the response (drains pending) before the socket closes
|
|
108
|
+
getBus().emit(Events.ASK_RESPONSE, {
|
|
109
|
+
requestId: "req-3",
|
|
110
|
+
cancelled: false,
|
|
111
|
+
results: [{ id: "q1", selectedOptions: ["A"] }],
|
|
112
|
+
});
|
|
113
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
114
|
+
client.destroy();
|
|
115
|
+
await new Promise((r) => setTimeout(r, 50)); // close handler ran
|
|
116
|
+
|
|
117
|
+
expect(cancels.length).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/spawn.ts
CHANGED
|
@@ -16,6 +16,15 @@ export interface SpawnOptions {
|
|
|
16
16
|
agent: AgentConfig | undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the model for a subagent run: agent config model > call model >
|
|
21
|
+
* active (parent) model. Shared by the fork path (spawnSubagent) and the
|
|
22
|
+
* bridge path (dispatch) so both resolve identically.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveModel(options: Pick<SpawnOptions, "model" | "activeModel"> & { agent?: AgentConfig | undefined }): string | undefined {
|
|
25
|
+
return options.agent?.model ?? options.model ?? options.activeModel;
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
/**
|
|
20
29
|
* Resolve the pi binary path.
|
|
21
30
|
*
|
|
@@ -67,17 +76,22 @@ function resolvePiBinary(): string {
|
|
|
67
76
|
* Start a Unix socket server that bridges the child's ask tool to the parent bus.
|
|
68
77
|
*
|
|
69
78
|
* The child's ask tool (in packages/ask) connects to PI_SUBAGENT_SOCKET and sends:
|
|
70
|
-
* { type: "ask_request", requestId, questions } as a JSON line
|
|
79
|
+
* { type: "ask_request", requestId, questions, toolCallId } as a JSON line
|
|
71
80
|
* and waits for:
|
|
72
81
|
* { type: "ask_response", requestId, cancelled, results } as a JSON line
|
|
73
82
|
*
|
|
74
|
-
* We forward the request onto the bus (ASK_REQUEST
|
|
75
|
-
*
|
|
76
|
-
* to the socket connection.
|
|
83
|
+
* We forward the request onto the bus (ASK_REQUEST, carrying toolCallId), the
|
|
84
|
+
* ask package (or the bridge, in bridge mode) answers it, then emits
|
|
85
|
+
* ASK_RESPONSE on the bus, and we write it back to the socket connection.
|
|
86
|
+
*
|
|
87
|
+
* When the socket closes (child exit, or the child's own 5-min timeout →
|
|
88
|
+
* socket.end()) we signal the bridge via ASK_CANCEL for any still-pending ask.
|
|
89
|
+
* We do NOT emit ASK_RESPONSE ourselves — the bridge is the sole emitter
|
|
90
|
+
* (no double-decrement of its refcount).
|
|
77
91
|
*
|
|
78
92
|
* Returns the socket path and a cleanup function.
|
|
79
93
|
*/
|
|
80
|
-
function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
|
|
94
|
+
export function startAskSocketServer(agentName: string): { socketPath: string; cleanup: () => void } {
|
|
81
95
|
// Use named pipes on Windows, Unix domain sockets elsewhere.
|
|
82
96
|
// Linux socket path limit is 108 chars — keep it short.
|
|
83
97
|
const id = randomUUID().slice(0, 8);
|
|
@@ -110,6 +124,8 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
|
|
|
110
124
|
|
|
111
125
|
const server = net.createServer((socket) => {
|
|
112
126
|
let buffer = "";
|
|
127
|
+
// requestIds seen on this socket (for the ASK_CANCEL wiring below)
|
|
128
|
+
const socketRequestIds = new Set<string>();
|
|
113
129
|
|
|
114
130
|
socket.on("data", (chunk: Buffer) => {
|
|
115
131
|
buffer += chunk.toString("utf-8");
|
|
@@ -124,6 +140,7 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
|
|
|
124
140
|
type: string;
|
|
125
141
|
requestId: string;
|
|
126
142
|
questions: unknown[];
|
|
143
|
+
toolCallId?: string;
|
|
127
144
|
};
|
|
128
145
|
if (msg.type === "ask_request") {
|
|
129
146
|
// Register write-back so ASK_RESPONSE handler can find this socket
|
|
@@ -134,11 +151,14 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
|
|
|
134
151
|
// socket already closed
|
|
135
152
|
}
|
|
136
153
|
});
|
|
137
|
-
|
|
154
|
+
socketRequestIds.add(msg.requestId);
|
|
155
|
+
// Forward to bus — the ask package (or the bridge, in bridge mode)
|
|
156
|
+
// answers it; toolCallId rides along for Client correlation
|
|
138
157
|
getBus().emit(Events.ASK_REQUEST, {
|
|
139
158
|
source: `subagent:${agentName}`,
|
|
140
159
|
requestId: msg.requestId,
|
|
141
160
|
questions: msg.questions,
|
|
161
|
+
toolCallId: msg.toolCallId,
|
|
142
162
|
});
|
|
143
163
|
}
|
|
144
164
|
} catch {
|
|
@@ -147,6 +167,19 @@ function startAskSocketServer(agentName: string): { socketPath: string; cleanup:
|
|
|
147
167
|
}
|
|
148
168
|
});
|
|
149
169
|
|
|
170
|
+
// Child exit (or the child's own 5-min timeout → socket.end()) closes the
|
|
171
|
+
// socket: signal the bridge via ASK_CANCEL for any still-pending ask so it
|
|
172
|
+
// can cancel the relayed Client request. We only signal — the bridge is
|
|
173
|
+
// the sole ASK_RESPONSE emitter (no double-decrement of its refcount).
|
|
174
|
+
socket.on("close", () => {
|
|
175
|
+
for (const rid of socketRequestIds) {
|
|
176
|
+
if (pending.has(rid)) {
|
|
177
|
+
getBus().emit(Events.ASK_CANCEL, { requestId: rid, source: `subagent:${agentName}` });
|
|
178
|
+
pending.delete(rid);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
150
183
|
socket.on("error", () => { /* connection dropped */ });
|
|
151
184
|
});
|
|
152
185
|
|
|
@@ -181,7 +214,7 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
|
181
214
|
const args: string[] = ["--mode", "json", "--no-session", "-p"];
|
|
182
215
|
|
|
183
216
|
// Model: agent.model > options.model > options.activeModel
|
|
184
|
-
const model = options
|
|
217
|
+
const model = resolveModel(options);
|
|
185
218
|
if (model) {
|
|
186
219
|
args.push("--model", model);
|
|
187
220
|
}
|
|
@@ -245,7 +278,12 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
|
|
|
245
278
|
forceKill.unref();
|
|
246
279
|
}
|
|
247
280
|
};
|
|
248
|
-
|
|
281
|
+
// A signal that is ALREADY aborted never dispatches its "abort" event (it
|
|
282
|
+
// fires exactly once, at abort time), so addEventListener alone would never
|
|
283
|
+
// kill the child — call the handler directly in that case. (kill is
|
|
284
|
+
// idempotent via the child.killed guard, so both branches coexist safely.)
|
|
285
|
+
if (options.signal.aborted) abortHandler();
|
|
286
|
+
else options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
249
287
|
child.on("exit", () => options.signal!.removeEventListener("abort", abortHandler));
|
|
250
288
|
}
|
|
251
289
|
|
package/src/stream.test.ts
CHANGED
|
@@ -192,6 +192,42 @@ describe("streamEvents todo mirroring", () => {
|
|
|
192
192
|
]);
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
+
it("stows raw args under the alias key todo_list (result has no details)", async () => {
|
|
196
|
+
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
197
|
+
const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
|
|
198
|
+
updates.push(p as (typeof updates)[number]),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
await finishWith(
|
|
202
|
+
[
|
|
203
|
+
{
|
|
204
|
+
type: "tool_execution_start",
|
|
205
|
+
toolCallId: "t-alias",
|
|
206
|
+
toolName: "manage_todo_list",
|
|
207
|
+
args: {
|
|
208
|
+
operation: "write",
|
|
209
|
+
todo_list: [{ id: 1, title: "Fix auth", status: "not-started" }],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
type: "tool_execution_end",
|
|
214
|
+
toolCallId: "t-alias",
|
|
215
|
+
toolName: "manage_todo_list",
|
|
216
|
+
isError: false,
|
|
217
|
+
result: { content: [{ type: "text", text: "ok" }] },
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
{ agent: "t-alias" },
|
|
221
|
+
);
|
|
222
|
+
off();
|
|
223
|
+
|
|
224
|
+
expect(updates).toHaveLength(1);
|
|
225
|
+
expect(updates[0]!.source).toBe("subagent:t-alias");
|
|
226
|
+
expect(updates[0]!.todos).toEqual([
|
|
227
|
+
{ content: "Fix auth", status: "pending" },
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
195
231
|
it("does not crash or emit when the end event has no matching start", async () => {
|
|
196
232
|
const updates: Array<{ source: string; todos: unknown[] }> = [];
|
|
197
233
|
const off = getBus().on(Events.TODOS_UPDATE, (p: unknown) =>
|
package/src/stream.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createInterface } from "node:readline";
|
|
|
2
2
|
import type { ChildProcess } from "node:child_process";
|
|
3
3
|
import type { StreamState, SubagentProgress, SubagentResult } from "./types.js";
|
|
4
4
|
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
5
|
-
import { normalizeTodoItems } from "@pi-archimedes/todo/prepare-args";
|
|
5
|
+
import { normalizeTodoItems, pickTodoListRaw } from "@pi-archimedes/todo/prepare-args";
|
|
6
6
|
import {
|
|
7
7
|
type JsonEvent,
|
|
8
8
|
handleToolStart,
|
|
@@ -150,7 +150,9 @@ export function streamEvents(
|
|
|
150
150
|
emitProgress();
|
|
151
151
|
if (event.toolName === "manage_todo_list" && typeof event.toolCallId === "string") {
|
|
152
152
|
const args = event.args as Record<string, unknown> | undefined;
|
|
153
|
-
|
|
153
|
+
// Raw (pre-prepareArguments) args: the child model may have used
|
|
154
|
+
// an alias key (todo_list/todos) — pickTodoListRaw covers it.
|
|
155
|
+
const todoList = pickTodoListRaw(args);
|
|
154
156
|
if (Array.isArray(todoList)) {
|
|
155
157
|
pendingTodoArgs.set(event.toolCallId, todoList);
|
|
156
158
|
}
|